<?php
/*
 * Copyright (c) 2013 Eugene Kuzmenko
 *
 * Permission  is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in  the Software without restriction, including without limitation the rights
 * to  use,  copy,  modify,  merge, publish, distribute, sublicense, and/or sell
 * copies  of  the  Software,  and  to  permit  persons  to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The  above  copyright  notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE  SOFTWARE  IS  PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED,  INCLUDING  BUT  NOT  LIMITED  TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS  FOR  A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS  OR  COPYRIGHT  HOLDERS  BE  LIABLE  FOR  ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * For more information, see <http://www.aljey.com>.
 */

namespace Al\Core\Lib\DOM;

use Al\Core\Lib\Service;

/**
 * Query DOM structures based on CSS selectors.
 *
 * @author Eugene Kuzmenko <mail@aljey.com>
 */
class Query {

  /**
   * @var \DOMXPath The xpath object.
   */
  private $xpath;

  /**
   * @var string The type of the document loaded.
   */
  private $type = 'html';

  /**
   * @var \DOMDocument The document object.
   */
  private $domDoc;

  /**
   * Constructor
   *
   * @param string $document The document string.
   * @throws \RuntimeException
   */
  public function __construct($document) {
    libxml_use_internal_errors(true);
    libxml_disable_entity_loader(true);
    $this->domDoc = new \DOMDocument('1.0');
    $document = (string)$document;
    $namespace = null;
    if ('<?xml' == substr(trim($document), 0, 5)) {
      $matches = array();
      if (preg_match('/<html[^>]*xmlns="([^"]+)"[^>]*>/i', $document,
          $matches)) {
        $namespace = $matches[1];
        $success = $this->domDoc->loadHTML($document);
      } else {
        $success = $this->domDoc->loadXML($document);
        foreach ($this->domDoc->childNodes as $child) {
          if ($child->nodeType === XML_DOCUMENT_TYPE_NODE) {
            throw new \RuntimeException(
              'Invalid XML: Detected use of illegal DOCTYPE');
          }
        }
        $this->type = 'xml';
      }
    } else {
      $success = $this->domDoc->loadHTML($document);
    }
    if (!$success) {
      throw new \RuntimeException('Error parsing document.');
    }
    $this->xpath = new \DOMXPath($this->domDoc);
    if (null !== $namespace) {
      $this->xpath->registerNamespace(0, $namespace);
    }
  }

  /**
   * Returns a reference to the document.
   *
   * @return \DOMDocument The document object.
   */
  public function getDocument() {
    return $this->domDoc;
  }

  /**
   * Transform a CSS expression to XPath.
   *
   * @param string $path A CSS selector.
   * @return string An xpath selector.
   */
  private function transform($path) {
    $path = (string)$path;
    if (strstr($path, ',')) {
      $paths = explode(',', $path);
      $expressions = array();
      foreach ($paths as $path) {
        $xpath = $this->transform(trim($path));
        if (is_string($xpath)) {
          $expressions[] = $xpath;
        } elseif (is_array($xpath)) {
          $expressions = array_merge($expressions, $xpath);
        }
      }
      return implode('|', $expressions);
    }
    $paths = array('//');
    $path = preg_replace('|\s+>\s+|', '>', $path);
    $segments = preg_split('/\s+/', $path);
    foreach ($segments as $key => $segment) {
      $pathSegment = str_replace('**', '*',
        preg_replace('|\.([a-z][a-z0-9_-]*)|i',
          "[contains(concat(' ', normalize-space(@class), ' '), ' \$1 ')]",
          preg_replace_callback('|\[([a-z0-9_-]+)\*=[\'"]([^\'"]+)[\'"]\]|i',
            function ($matches) {
              return "[contains(@" . strtolower($matches[1]) .
                ", '{$matches[2]}')]";
            },
            preg_replace_callback('|\[([a-z0-9_-]+)~=[\'"]([^\'"]+)[\'"]\]|i',
              function ($matches) {
                return "[contains(concat(' ', normalize-space(@" .
                  strtolower($matches[1]) . "), ' '), ' {$matches[2]} ')]";
              },
              preg_replace_callback('|\[([a-z0-9_-]+)=[\'"]([^\'"]+)[\'"]\]|i',
                function($matches) {
                  return '[@' . strtolower($matches[1]) . "='{$matches[2]}']";
                },
                preg_replace('|(?<![a-z0-9_-])(\[@id=)|i', '*$1', preg_replace(
                  '|#([a-z][a-z0-9_-]*)|i', '[@id=\'$1\']', str_replace('>',
                  '/', $segment))))))));
      if (0 == $key) {
        $paths[0] .= (0 === strpos($pathSegment, '[contains(')) ? ('*' .
          ltrim($pathSegment, '*')) : $pathSegment;
        continue;
      }
      if (0 === strpos($pathSegment, '[contains(')) {
        foreach ($paths as $pathKey => $xpath) {
          $paths[$pathKey] .= '//*' . ltrim($pathSegment, '*');
          $paths[] = $xpath . $pathSegment;
        }
      } else {
        foreach ($paths as $pathKey => $xpath) {
          $paths[$pathKey] .= '//' . $pathSegment;
        }
      }
    }
    if (1 == count($paths)) {
      return $paths[0];
    }
    return implode('|', $paths);
  }

  /**
   * Returns the modified version of the document.
   *
   * @return string The document string.
   */
  public function __toString() {
    $node = func_num_args() ? func_get_arg(0) : null;
    return Service::get('template')->trimHTML(($this->type === 'html') ?
      $this->domDoc->saveHTML($node) : $this->domDoc->saveXML($node));
  }

  /**
   * Searches the document, similar to $(selector, context) in the jQuery world.
   *
   * @param string|\DomNode|NodeList $selector
   * @param string|\DomNode|NodeList $context [optional]
   * @return NodeList
   */
  public function __invoke($selector, $context = null) {
    if ($selector instanceof NodeList) {
      return $selector;
    }
    if ($selector instanceof \DOMNode) {
      $list = new NodeList(new \DOMNodeList(), $this);
      $list[] = $selector;
      return $list;
    }
    if (($selector instanceof \Traversable) || is_array($selector)) {
      $list = new NodeList(new \DOMNodeList(), $this);
      foreach ($selector as $item) {
        foreach ($this($item, $context) as $node) {
          $list[] = $node;
        }
      }
      return $list;
    }
    if (is_string($selector)) {
      $len = strlen($selector);
      if ('<' === $selector[0] && '>' === $selector[$len - 1] && 3 <= $len) {
        $match = array(null, $selector, null);
        $test = true;
      } else {
        $match = array();
        $test = preg_match('/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/', $selector,
          $match);
      }
      if ($test && ($match[1] || !$context)) {
        if ($match[1]) {
          $parsed = array();
          if (preg_match('/^<(\w+)\s*\/?>(?:<\/\1>|)$/', $match[1], $parsed)) {
            $list = new NodeList(new \DOMNodeList(), $this);
            $list[] = $this->domDoc->createElement($parsed[1]);
            return $list;
          }
          if (!preg_match('/<|&#?\w+;/', $match[1])) {
            $list = new NodeList(new \DOMNodeList(), $this);
            $list[] = $this->domDoc->createTextNode($match[1]);
            return $list;
          }
          $query = new Query($match[1]);
          return new NodeList($this->domDoc->importNode($query->xpath->query(
            (string)$this->transform('body'))->item(0), true)->childNodes,
            $this);
        }
        $list = new NodeList(new \DOMNodeList(), $this);
        $list[] = $this->domDoc->getElementById($match[2]);
        return $list;
      }
      if ($context) {
        $context = $this($context)->first();
        $context = isset($context[0]) ? $context[0] : null;
      }
      return new NodeList($this->xpath->query((string)$this
        ->transform($selector), $context), $this);
    }
    return new NodeList(new \DOMNodeList(), $this);
  }

}